package org.apereo.cas.authentication.attribute;

import org.apereo.cas.authentication.principal.attribute.PersonAttributeDaoFilter;
import org.apereo.cas.authentication.principal.attribute.PersonAttributes;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.util.LinkedCaseInsensitiveMap;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Provides common functionality for DAOs using a set of attribute values from the seed to
 * perform a query. Ensures the necessary attributes to run the query exist on the seed and
 * organizes the values into an argument array.
 *
 * @author Eric Dalquist
 * @since 7.1.0
 */
@Slf4j
public abstract class AbstractQueryPersonAttributeDao<QB> extends AbstractDefaultAttributePersonAttributeDao {
    @Getter
    private Map<String, Set<String>> queryAttributeMapping;
    @Getter
    private Map<String, Set<String>> resultAttributeMapping;
    @Getter
    @Setter
    private Map<String, CaseCanonicalizationMode> caseInsensitiveResultAttributes;
    @Getter
    @Setter
    private Map<String, CaseCanonicalizationMode> caseInsensitiveQueryAttributes;
    @Getter
    @Setter
    private CaseCanonicalizationMode defaultCaseCanonicalizationMode = CaseCanonicalizationMode.LOWER;
    @Getter
    @Setter
    private CaseCanonicalizationMode usernameCaseCanonicalizationMode = CaseCanonicalizationMode.NONE;
    private Set<String> possibleUserAttributeNames;
    @Getter
    @Setter
    private boolean requireAllQueryAttributes;
    @Getter
    @Setter
    private boolean useAllQueryAttributes = true;
    @Getter
    @Setter
    private String unmappedUsernameAttribute;

    /**
     * Sets result attribute mapping.
     *
     * @param resultAttributeMapping the result attribute mapping
     */
    public void setResultAttributeMapping(final Map<String, ?> resultAttributeMapping) {
        val parsedResultAttributeMapping = parseAttributeToAttributeMapping(resultAttributeMapping);
        val userAttributes = flattenCollection(parsedResultAttributeMapping.values());
        this.resultAttributeMapping = parsedResultAttributeMapping;
        this.possibleUserAttributeNames = new LinkedHashSet(userAttributes);
    }

    @Override
    public final Set<PersonAttributes> getPeopleWithMultivaluedAttributes(final Map<String, List<Object>> query,
                                                                          final PersonAttributeDaoFilter filter,
                                                                          final Set<PersonAttributes> resultPeople) {
        var queryBuilder = generateQuery(query);
        if (queryBuilder == null && (queryAttributeMapping != null || useAllQueryAttributes)) {
            LOGGER.debug("No queryBuilder was generated for query [{}], null will be returned", query);
            return null;
        }

        var usernameAttributeProvider = getUsernameAttributeProvider();
        var username = usernameAttributeProvider.getUsernameFromQuery(query);

        var unmappedPeople = getPeopleForQuery(queryBuilder, username);
        if (unmappedPeople == null) {
            return null;
        }
        return unmappedPeople.stream().map(this::mapPersonAttributes).collect(Collectors.toCollection(LinkedHashSet::new));
    }


    /**
     * Executes the query for the generated queryBuilder object and returns a list where each entry is a Map of
     * attributes for a single IPersonAttributes.
     *
     * @param queryBuilder  The query generated by calls to {@link #appendAttributeToQuery(Object, String, List)}
     * @param queryUserName The username passed in the query map, if no username attribute existed in the query Map null is provided.
     * @return The list of IPersons found by the query. The user attributes should be using the raw names from the data layer.
     */
    protected abstract List<PersonAttributes> getPeopleForQuery(QB queryBuilder, String queryUserName);

    /**
     * Append the attribute and its canonicalized value/s to the
     * {@code queryBuilder}. Uses {@code queryAttribute} to determine whether or
     * not the value/s should be canonicalized. I.e. the behavior is controlled
     * by {@link #setCaseInsensitiveQueryAttributes(Map)}.
     *
     * <p>This method is only concerned with canonicalizing the query attribute
     * value. It is still up to the subclass to canonicalize the data-layer
     * attribute value prior to comparison, if necessary. For example, if
     * the data layer is a case-sensitive relational database and attributes
     * therein are stored in mixed case, but comparison should be
     * case-insensitive, the relational column reference would need to be
     * wrapped in a {@code lower()} or {@code upper()} function. (This, of
     * course, needs to be handled with care, since it can lead to table
     * scanning if the store does not support function-based indexes.) Such
     * data-layer canonicalization would be unnecessary if the data layer is
     * case-insensitive or stores values in the same canonicalized form as has
     * been configured for the app-layer attribute.
     *
     * @param queryBuilder   The sub-class specific query builder object
     * @param queryAttribute The full attribute name to append
     * @param dataAttribute  The full attribute name to append
     * @param queryValues    The values for the data attribute
     * @return An updated queryBuilder
     */
    protected QB appendCanonicalizedAttributeToQuery(final QB queryBuilder, final String queryAttribute, final String dataAttribute, final List<Object> queryValues) {
        val canonicalizedQueryValues = canonicalizeAttribute(queryAttribute, queryValues, caseInsensitiveQueryAttributes);
        LOGGER.debug("Adding attribute [{}] with value [{}] to query builder [{}]", queryAttribute, queryValues, queryBuilder);
        return appendAttributeToQuery(queryBuilder, dataAttribute, canonicalizedQueryValues);
    }

    /**
     * Append the attribute and value to the queryBuilder.
     *
     * @param queryBuilder  The sub-class specific query builder object
     * @param dataAttribute The full attribute name to append
     * @param queryValues   The values for the data attribute
     * @return An updated queryBuilder
     */
    protected abstract QB appendAttributeToQuery(QB queryBuilder, String dataAttribute, List<Object> queryValues);

    /**
     * Generates a query using the queryBuilder object passed by the subclass. Attribute/Value pairs are added to the
     * queryBuilder by calling {@link #appendCanonicalizedAttributeToQuery(Object, String, String, List)} which calls
     * {@link #appendAttributeToQuery(Object, String, List)}. Attributes are only added if
     * there is an attributed mapped in the queryAttributeMapping.
     *
     * @param query The query Map to populate the queryBuilder with.
     * @return The fully populated query builder.
     */
    protected final QB generateQuery(final Map<String, List<Object>> query) {
        QB queryBuilder = null;

        if (queryAttributeMapping != null && !queryAttributeMapping.isEmpty()) {
            for (val queryAttrEntry : queryAttributeMapping.entrySet()) {
                var queryAttr = queryAttrEntry.getKey();
                var queryValues = query.get(queryAttr);
                if (queryValues != null) {
                    val dataAttributes = queryAttrEntry.getValue();
                    if (dataAttributes == null) {
                        queryBuilder = appendCanonicalizedAttributeToQuery(queryBuilder, queryAttr, null, queryValues);
                    } else {
                        for (val dataAttribute : dataAttributes) {
                            queryBuilder = appendCanonicalizedAttributeToQuery(queryBuilder, queryAttr, dataAttribute, queryValues);
                        }
                    }
                } else if (requireAllQueryAttributes) {
                    LOGGER.debug("Query [{}] does not contain all attributes as specified in [{}]", query, queryAttributeMapping);
                    return null;
                }
            }
        } else if (useAllQueryAttributes) {
            for (val queryAttrEntry : query.entrySet()) {
                val queryKey = queryAttrEntry.getKey();
                val queryValues = queryAttrEntry.getValue();
                queryBuilder = appendCanonicalizedAttributeToQuery(queryBuilder, queryKey, queryKey, queryValues);
            }
        }

        queryBuilder = finalizeQueryBuilder(queryBuilder, query);
        LOGGER.debug("Generated query builder [{}] from query Map [{}]", queryBuilder, query);
        return queryBuilder;
    }

    protected QB finalizeQueryBuilder(final QB queryBuilder, final Map<String, List<Object>> query) {
        return queryBuilder;
    }

    /**
     * Uses resultAttributeMapping to return a copy of the IPersonAttributes with only the attributes specified in
     * resultAttributeMapping mapped to their result attribute names.
     *
     * @param person The IPersonAttributes to map attributes for
     * @return A copy of the IPersonAttributes with mapped attributes, the original IPersonAttributes if resultAttributeMapping is null.
     */
    protected final PersonAttributes mapPersonAttributes(final PersonAttributes person) {
        var personAttributes = person.getAttributes();

        val mappedAttributes = new LinkedCaseInsensitiveMap<List<Object>>();
        if (resultAttributeMapping == null) {
            if (caseInsensitiveResultAttributes != null && !caseInsensitiveResultAttributes.isEmpty()) {
                for (val attribute : personAttributes.entrySet()) {
                    var attributeName = attribute.getKey();
                    mappedAttributes.put(attributeName, canonicalizeAttribute(attributeName, attribute.getValue(), caseInsensitiveResultAttributes));
                }
            } else {
                mappedAttributes.putAll(personAttributes);
            }
        } else {
            for (val resultAttrEntry : resultAttributeMapping.entrySet()) {
                val dataKey = resultAttrEntry.getKey();
                var resultKeys = resultAttrEntry.getValue();
                if (resultKeys == null) {
                    resultKeys = Set.of(dataKey);
                }
                if (resultKeys.size() == 1 && resultKeys.stream().allMatch(s -> !s.isEmpty() && s.charAt(s.length() - 1) == ';')) {
                    var allKeys = personAttributes.keySet().stream().filter(name -> name.startsWith(dataKey + ';')).toList();
                    for (val resultKey : allKeys) {
                        var value = personAttributes.get(resultKey);
                        value = canonicalizeAttribute(resultKey, value, caseInsensitiveResultAttributes);
                        mappedAttributes.put(resultKey, value);
                    }
                } else if (personAttributes.containsKey(dataKey)) {
                    var value = personAttributes.get(dataKey);
                    for (val resultKey : resultKeys) {
                        value = canonicalizeAttribute(resultKey, value, caseInsensitiveResultAttributes);
                        if (resultKey == null) {
                            mappedAttributes.put(dataKey, value);
                        } else {
                            mappedAttributes.put(resultKey, value);
                        }
                    }
                }
            }
        }

        val name = person.getName();
        if (name != null) {
            return new SimplePersonAttributes(usernameCaseCanonicalizationMode.canonicalize(name), mappedAttributes);
        }
        val usernameAttribute = getConfiguredUserNameAttribute();
        return SimplePersonAttributes.fromAttribute(usernameAttribute, mappedAttributes).canonicalize(usernameCaseCanonicalizationMode);
    }

    /**
     * Canonicalize the attribute values if they are present in the config map.
     *
     * @param key    attribute key
     * @param value  list of attribute values
     * @param config map of attribute names to canonicalization key for the attribute
     * @return if configured to do so, returns a canonicalized list of values.
     */
    protected List<Object> canonicalizeAttribute(final String key, final List<Object> value, final Map<String, CaseCanonicalizationMode> config) {
        if (value == null || value.isEmpty() || config == null || !config.containsKey(key)) {
            return value;
        }
        var canonicalizationMode = config.get(key);
        if (canonicalizationMode == null) {
            canonicalizationMode = defaultCaseCanonicalizationMode;
        }
        val canonicalizedValues = new ArrayList<>(value.size());
        for (val origValue : value) {
            if (origValue instanceof final String stringValue) {
                canonicalizedValues.add(canonicalizationMode.canonicalize(stringValue, Locale.ENGLISH));
            } else {
                canonicalizedValues.add(origValue);
            }
        }
        return canonicalizedValues;
    }

    /**
     * Indicates which attribute found by the subclass should be taken as the
     * 'username' attribute.  (E.g. 'uid' or 'sAMAccountName')  NOTE:  Any two
     * instances if BasePersonImpl with the same username are considered
     * equal.  Since {@link #getUsernameAttributeProvider()} should never return
     * null, this method should never return null either.
     *
     * @return The name of the attribute corresponding to the  user's username.
     */
    protected String getConfiguredUserNameAttribute() {
        if (unmappedUsernameAttribute != null) {
            return unmappedUsernameAttribute;
        }
        val usernameAttributeProvider = getUsernameAttributeProvider();
        return usernameAttributeProvider.getUsernameAttribute();
    }

    /**
     * Indicates whether the value from {@link #getConfiguredUserNameAttribute()}
     * was configured explicitly.  A return value of {@code false} means
     * that the value from {@link #getConfiguredUserNameAttribute()} is a
     * default, and should not be used over a username passed in the query.
     *
     * @return {@code true} If the 'unmappedUsernameAttribute' property was
     * set explicitly, otherwise {@code false}
     */
    protected boolean isUserNameAttributeConfigured() {
        return unmappedUsernameAttribute != null;
    }


    /**
     * Map from query attribute names to data-layer attribute names to use when building the query. If an ordered Map is
     * passed in the order of the attributes will be honored when building the query.
     * If not set query attributes will be used directly from the query Map.
     *
     * @param queryAttributeMapping the queryAttributeMapping to set
     */
    public void setQueryAttributeMapping(final Map<String, ?> queryAttributeMapping) {
        this.queryAttributeMapping = parseAttributeToAttributeMapping(queryAttributeMapping);
    }

    @Override
    public Set<String> getPossibleUserAttributeNames(final PersonAttributeDaoFilter filter) {
        return Set.copyOf(this.possibleUserAttributeNames);
    }
}
